"use strict";

const fs = require("node:fs");
const nodePath = require("node:path");
const parser = require("@babel/parser");
const t = require("@babel/types");
const generate = require("@babel/generator").default;
const traverse = require("@babel/traverse").default;
const resolve = require("resolve");
const { camelCaseToDashed } = require("../lib/utils/camelize");

const { basename, dirname } = nodePath;

let uniqueIndex = 0;
function getUniqueIndex() {
  return uniqueIndex++;
}

const propertyFiles = fs
  .readdirSync(nodePath.resolve(__dirname, "../lib/properties"))
  .filter(function (property) {
    return property.substr(-3) === ".js";
  });
const outFile = fs.createWriteStream(
  nodePath.resolve(__dirname, "../lib/generated/properties.js"),
  {
    encoding: "utf-8"
  }
);
const dateToday = new Date();
const [dateTodayFormatted] = dateToday.toISOString().split("T");
const output = `"use strict";
// autogenerated - ${dateTodayFormatted}
// https://www.w3.org/Style/CSS/all-properties.en.html
\n`;
outFile.write(output);

function isModuleDotExports(node) {
  return (
    t.isMemberExpression(node, { computed: false }) &&
    t.isIdentifier(node.object, { name: "module" }) &&
    t.isIdentifier(node.property, { name: "exports" })
  );
}
function isRequire(node, filename) {
  if (
    t.isCallExpression(node) &&
    t.isIdentifier(node.callee, { name: "require" }) &&
    node.arguments.length === 1 &&
    t.isStringLiteral(node.arguments[0])
  ) {
    const relative = node.arguments[0].value;
    const fullPath = resolve.sync(relative, { basedir: dirname(filename) });
    return {
      relative,
      fullPath
    };
  }
  return false;
}

// step 1: parse all files and figure out their dependencies
const parsedFilesByPath = {};
propertyFiles.forEach(function (propertyFile) {
  const filename = nodePath.resolve(__dirname, `../lib/properties/${propertyFile}`);
  const src = fs.readFileSync(filename, "utf8");
  const property = basename(propertyFile, ".js");
  const ast = parser.parse(src);
  const dependencies = [];
  traverse(ast, {
    enter(path) {
      const r = isRequire(path.node, filename);
      if (r) {
        dependencies.push(r.fullPath);
      }
    }
  });
  parsedFilesByPath[filename] = {
    filename,
    property,
    ast,
    dependencies
  };
});

// step 2: serialize the files in an order where dependencies are always above
//         the files they depend on
const externalDependencies = [];
const parsedFiles = [];
const addedFiles = {};
function addFile(filename, dependencyPath) {
  if (dependencyPath.indexOf(filename) !== -1) {
    throw new Error(
      `Circular dependency: ${dependencyPath
        .slice(dependencyPath.indexOf(filename))
        .concat([filename])
        .join(" -> ")}`
    );
  }
  const file = parsedFilesByPath[filename];
  if (addedFiles[filename]) {
    return;
  }
  if (file) {
    file.dependencies.forEach(function (dependency) {
      addFile(dependency, dependencyPath.concat([filename]));
    });
    parsedFiles.push(parsedFilesByPath[filename]);
  } else {
    externalDependencies.push(filename);
  }
  addedFiles[filename] = true;
}
Object.keys(parsedFilesByPath).forEach(function (filename) {
  addFile(filename, []);
});

// Step 3: add files to output
// renaming exports to local variables `moduleName_export_exportName`
// and updating require calls as appropriate
const moduleExportsByPath = {};
const statements = [];
externalDependencies.forEach(function (filename, i) {
  const id = t.identifier(
    `external_dependency_${basename(filename, ".js").replace(/[^A-Za-z]/g, "")}_${i}`
  );
  moduleExportsByPath[filename] = { defaultExports: id };
  let relativePath = nodePath
    .relative(nodePath.resolve(`${__dirname}/../lib`), filename)
    .replaceAll(nodePath.sep, "/");
  if (relativePath[0] !== ".") {
    relativePath = `../${relativePath}`;
  }
  statements.push(
    t.variableDeclaration("var", [
      t.variableDeclarator(
        id,
        t.callExpression(t.identifier("require"), [t.stringLiteral(relativePath)])
      )
    ])
  );
});
function getRequireValue(node, file) {
  let r;
  // replace require("./foo").bar with the named export from foo
  if (t.isMemberExpression(node, { computed: false })) {
    r = isRequire(node.object, file.filename);
    if (r) {
      const e = moduleExportsByPath[r.fullPath];
      if (!e) {
        return;
      }
      if (!e.namedExports) {
        return t.memberExpression(e.defaultExports, node.property);
      }
      if (!e.namedExports[node.property.name]) {
        throw new Error(`${r.relative} does not export ${node.property.name}`);
      }
      return e.namedExports[node.property.name];
    }
  }
  // replace require("./foo") with the default export of foo
  r = isRequire(node, file.filename);
  if (r) {
    const e = moduleExportsByPath[r.fullPath];
    if (!e) {
      if (/^\.\.\//.test(r.relative)) {
        node.arguments[0].value = r.relative.substr(1);
      }
      return;
    }
    return e.defaultExports;
  }
}
parsedFiles.forEach(function (file) {
  const namedExports = {};
  const localVariableMap = {};

  traverse(file.ast, {
    enter(path) {
      // replace require calls with the corresponding value
      let r = getRequireValue(path.node, file);
      if (r) {
        path.replaceWith(r);
        return;
      }

      // if we see `var foo = require('bar')` we can just inline the variable
      // representing `require('bar')` wherever `foo` was used.
      if (
        t.isVariableDeclaration(path.node) &&
        path.node.declarations.length === 1 &&
        t.isIdentifier(path.node.declarations[0].id)
      ) {
        r = getRequireValue(path.node.declarations[0].init, file);
        if (r) {
          const newName = `compiled_local_variable_reference_${getUniqueIndex()}`;
          path.scope.rename(path.node.declarations[0].id.name, newName);
          localVariableMap[newName] = r;
          path.remove();
          return;
        }
      }

      // rename all top level functions to keep them local to the module
      if (t.isFunctionDeclaration(path.node) && t.isProgram(path.parent)) {
        path.scope.rename(path.node.id.name, `${file.property}_local_fn_${path.node.id.name}`);
        return;
      }

      // rename all top level variables to keep them local to the module
      if (t.isVariableDeclaration(path.node) && t.isProgram(path.parent)) {
        path.node.declarations.forEach(function (declaration) {
          path.scope.rename(
            declaration.id.name,
            `${file.property}_local_var_${declaration.id.name}`
          );
        });
        return;
      }

      // replace module.exports.bar with a variable for the named export
      if (
        t.isMemberExpression(path.node, { computed: false }) &&
        isModuleDotExports(path.node.object)
      ) {
        const { name } = path.node.property;
        const identifier = t.identifier(`${file.property}_export_${name}`);
        path.replaceWith(identifier);
        namedExports[name] = identifier;
      }
    }
  });
  traverse(file.ast, {
    enter(path) {
      if (t.isIdentifier(path.node) && Object.hasOwn(localVariableMap, path.node.name)) {
        path.replaceWith(localVariableMap[path.node.name]);
      }
    }
  });
  const defaultExports = t.objectExpression(
    Object.keys(namedExports).map(function (name) {
      return t.objectProperty(t.identifier(name), namedExports[name]);
    })
  );
  moduleExportsByPath[file.filename] = {
    namedExports,
    defaultExports
  };
  statements.push(
    t.variableDeclaration(
      "var",
      Object.keys(namedExports).map(function (name) {
        return t.variableDeclarator(namedExports[name]);
      })
    )
  );
  statements.push(...file.ast.program.body);
});
const propertyDefinitions = [];
parsedFiles.forEach(function (file) {
  const { property } = file;
  const propertyDefinition = `${property}_export_definition`;
  propertyDefinitions.push(
    t.objectProperty(t.identifier(property), t.identifier(propertyDefinition))
  );
  const dashed = camelCaseToDashed(property);
  if (property !== dashed) {
    propertyDefinitions.push(
      t.objectProperty(t.stringLiteral(dashed), t.identifier(propertyDefinition))
    );
  }
  if (/^webkit[A-Z]/.test(property)) {
    const pascal = property.replace(/^webkit/, "Webkit");
    propertyDefinitions.push(
      t.objectProperty(t.stringLiteral(pascal), t.identifier(propertyDefinition))
    );
  }
});
statements.push(
  t.expressionStatement(
    t.assignmentExpression(
      "=",
      t.memberExpression(t.identifier("module"), t.identifier("exports")),
      t.objectExpression(propertyDefinitions)
    )
  )
);
outFile.write(`${generate(t.program(statements)).code}\n`);
outFile.end(function (err) {
  if (err) {
    throw err;
  }
});
